平时开发中大家是否会遇到这些问题:
- 一个方法几百行,一个类上万行,不敢轻易修改这个类,即便只是为了熟悉业务阅读代码,血压也会升高
- 开发新业务时想复用老的服务,却发现复用比重写一套还困难
- 集合操作明明有简洁的api,却出现很多重复的for循环
每个项目的代码都有槽点,除了编码新功能,我们也需要不定期对不合格的老代码进行重构,从而提高代码的可读性、扩展性。近期重读《重构》,把一些好用的理念整理出来,分享给大家。
收敛可变范围
《重构》的作者认为“可变数据(mutable data)是软件错误的最大源头之一”,我比较认同这个观点。把数据的读写权限完全放开,看起来对业务修改数据更方便,但业务复杂之后,对数据的修改经常导致出乎意料的结果和难以发现的bug。在一处更新数据,却没有意识到另一处期望着完全不同的数据,于是一个功能失效了。如果是偶现bug,要找出问题的根源就会更加困难。解决这类问题可以减少对变量的依赖,将可变数据的写权限限制在较小范围。外部需要完整数据时,如果可以的话,请返回原始数据的副本。
以查询取代派生变量 Replace Derived Variable with Query
有些结果可以在获取时即时计算,不需要声明一个类变量实时维护。
下面这个例子,使用类变量mProduction记录所有mAdjustments元素中amount总和。在每次添加新的adjustment时,mProduction都需要更新。
1 | fun applyAdjustment(anAdjustment: Adjustment) { |
按照“以查询取代派生变量”的思路,可以对mAdjustments即时计算来获取amount总和,这样可以减少类变量的维护成本。使用Collection的pipeline api,同时对方法的名字进行微调,修改后的代码如下:
1 | fun getDiscontedTotal() = mAdjustments.map { it.amount }.sum() |
避免修改方法入参
下面这个例子,入参inputValue不仅在方法体中被修改,还作为方法的返回值使用。
1 | public int discount(int inputValue) { |
在Java中,经历这个方法后,外部传入的inputValue将会被修改,这种隐式变化会影响调用方的数据准确性,并且不会有任何提示。但在Kotlin中,方法参数是Val类型,不允许在方法体内被修改,所以数据不会出现这种隐式变化,也就减少bug产生的概率。这也是我更喜欢Kotlin的原因之一。
给入参inputValue换个更贴切的名字,方法体内使用局部变量进行逻辑处理,返回值也改用局部变量:
1 | public int discount(int originalInputValue) { |
拆分变量 Split Variable
如果一个变量在不同时刻表达不同内容,可以通过拆分变量保证一个变量只有一个用途。虽然看似变量多了,但代码逻辑更清晰。少出几个bug,也是提高研发效率的手段。
1 | var result = 2 * (height + width) |
通过拆分变量,以及使用合适的变量命名:
1 | val perimeter = 2 * (height + width) |
函数组合成类 Combine Functions into Class
如果一个类的几个函数形影不离的操作同一块数据,且这部分数据的读写范围可控,数据表达的概念相对独立,那么可以把这些函数连同变量独立成类。
类能明确的给这些函数提供一个共用的环境,在对象内部调用这些函数可以少传许多参数,从而简化函数调用。除了把已有的函数组织起来,重构过程中,我们还会有机会发现其他的数据计算逻辑,一并提取到新建的类中,进而简化原有类。
下面这个例子,为业务View添加了滑动帧率的性能监控,较多代码(例子已经简化过)和BizView的业务逻辑并不相关,这种场景就可以利用“函数组合成类”,把monitor、reporter、handle等逻辑独立成类,抽象为性能监控服务支撑业务。
1 | class BizView { |
分离查询函数和修改函数 Separate Query from Modifier
我们在设计方法时,如果一个方法既有查询功能,又有修改或和查询无关的逻辑,那么可以将查询和修改进行函数分离,确保调用者不会调用到有副作用的代码。
下面这个方法,遍历一份people名单,检查是否混进了miscreant。如果发现了miscreant,该函数会返回miscreant的名字,并拉响警报。如果人群中有多名miscreant,该函数也只汇报找出的第一名miscreant。alertForMiscreant()可谓身兼多职。
1 | fun alertForMiscreant(people: ArrayList<People>): String { |
使用分离逻辑,把查找miscreant的功能抽离出来变成findMiscreant(),让alertForMiscreant()只做警报功能,这样两个方法的职责更清晰。
1 | fun alertForMiscreant(people: ArrayList<People>) { |
简化条件表达式
《重构》的作者提到自己听过这样的观点“所有的条件逻辑都应该用多态取代,绝大多数if语句都应该被扫进历史的垃圾桶”。虽然实际编码中我们不会这么激进的理解条件表达式,但是条件表达式如果有非常复杂的分支结构,修改起来确实痛苦,还容易产生bug。更过分的case是,有些表达式的分支顺序有隐式依赖,比如调整分支的顺序就会引发奇怪的bug。
以多态取代条件表达式 Replace Conditional with Polymorphism
下面这个例子,Adapter中需要用到各种尺寸类型的乐高View,仅乐高View的初始化和数据刷新工作就占据Adapter类的较大篇幅。以后如果希望再加一种尺寸的乐高View,还会直接动到这个庞杂的Adapter。
1 | class LegoViewAdapter { |
如果使用多态优化乐高View的初始化和View刷新逻辑的条件分支:
1 | class LegoViewAdapter { |
- 引入多态,将各种尺寸的乐高View组装成一套继承体系
- 乐高View的初始化从Adapter中迁移出来,交给工厂,简洁Adapter的同时,提高乐高的复用性
- 引入乐高基类,表达公共逻辑
以卫语句取代嵌套条件表达式 Replace Nested Conditional with Guard Clauses
条件分支中,往往会有一些分支用来处理异常或特定情况,那就应该从条件表达式中抽离该分支,在条件表达式前面单独判断,减少分支结构。这样单独检查的语句称为卫语句(guard clauses)。
下面例子有多个特定分支,即dead、separate、retire。如果把这些特别情况放到条件分支,整个方法非常不优雅。
1 | fun getPayAmount() { |
使用卫语句进行优化:
1 | fun getPayAmount() { |
引入特例 Introduce Special Case
有些条件分支是用来处理“特例”情况,比如上面“以多态取代条件表达式”的示例,LegoViewFactory中无法识别的viewType就返回一个特例EmptyLegoView。当然,返回null对象作为兜底也是可行的,null对象是特例中的特例。
引入断言 Introduce Assertion
常常有这样的代码:只有当某个条件为true时,这段代码才会执行。比如方法的入参不能为null,平方根计算只能对正值处理。
下面这个例子,根据外部传入的数据刷新View前,需要判断下数据是否有效(LegoData的title不能为空),数据有效才能继续执行。
1 | class LegoView { |
在实际项目中,无效的数据,往往是代码(数据源)发生了bug。我们希望开发环境能够及时暴露出来,提醒开发者修复bug,减少线上影响面。暴露的方式很多,比如上报到监控平台或debug环境抛出异常等等。
1 | class LegoView { |
处理继承关系
虽然设计原则“组合优于继承”,但继承使用得当,也会有很多好处,比如可以用多态来处理条件逻辑。如果有几个函数都在根据类型码的取值采取不同的行为,多态就显得特别有用。引入子类之后,就可以用“以多态取代条件表达式”来处理这些函数。
也可以使用简单的重构方法让父类和子类的职责更清晰。比如父类中有几个只在少量子类中使用的方法或变量,那应该把这些成员下放给这些子类,减小父类的维护负担。反之,子类中的成员上移给父类。
移动成员,让继承职责更清晰
成员上移:如果某个字段在各个子类中都有用到且含义相同,或者某个方法在各个子类中的函数体大致相同,就需要把这些字段或方法上移到父类中。个别子类的方法实现若有不同,子类可以复写方法。
成员下移:如果父类中某个字段或方法,只与一个或少许几个子类有关,那最好是把这个成员下移到具体子类中,尽量让父类只处理公共逻辑。
委托取代继承 Replace Subclass with Delegate
在部分语言体系,继承有个比较大的短板是“这张牌只能打一次”,即单继承。另外,继承会让类关系耦合加重,比如我们不能为了部分子类的需要,贸然修改父类逻辑,需要明确父类的修改对所有子类都不会产生负面影响。当继承体系复杂后,基类的维护就变得举步维艰。
下面例子中,Bird继承体系主要表达了鸟的种类,有欧洲燕EuropeanSwallow、非洲燕AfricanSwallow、挪威蓝鹦鹉NorwegianBlueParrot。不同种类的Brid,有不同的airSpeed和plumage。
1 | object BirdFactory { |
如果Bird中有些是野生的,有些是家养的,两类鸟之间有较大差异。容易想到的方式是新建两个子类WildBird和CaptiveBird。由于单继承的限制,我们没办法再用直接继承的方式既表达鸟的种类,还能表达鸟的生存属性。
那么我们可以通过代理,将鸟的种类逻辑从Bird继承体系中抽离出来:
1 | object BirdFactory { |
这个例子的几点改动说明:
- 通过代理,取代了Bird种类的继承体系,这样Bird就重获被继承的能力
- 三个Delegate分别替换了几个Bird子类,通过SpeciesDelegate完全托管了species这套继承体系,整体逻辑更内聚。后续如果添加新的bird spices,也只用修改SpeciesDelegate,不会改动到Bird。
- 添加接口IBirdSpecies,表达不同种类的鸟的具体区别
- 提炼子类的公共行为,添加基类SpeciesDelegate
- 同样,鸟类的生存属性(Wild、Captive)也可以用这个思维进行代理,Bird就可以通过组合两套Delegate体系表达更丰富的功能
通过这个例子,想分享两个观点:
- 可以看到该例并没有完全舍弃继承,而是利用代理+继承,替代了初始的继承结构。所以“委托取代继承”是一个相对概念,很多事情不是非0即1。
- 另一个观点是,什么时候使用继承,什么时候使用代理取代继承,需要看业务形态。当业务逻辑没那么复杂时,两者的差别并不明显,同时继承逻辑容易被理解,所以一开始写代码使用继承结构并无大碍。但当继承结构要处理不同体系的行为时,就该使用代理重构代码了。
优化封装结构
以查询取代临时变量 Replace Temp with Query
使用临时变量可以避免在一个方法中对一段逻辑重复计算。如果对这段逻辑提炼,改用查询函数,可以避免同一段逻辑在多个方法中重复计算。
下面这个例子,计算basePrice的代码在类中的多个方法都出现(代码进行了简化),那这段逻辑就应该提炼到方法,以查询取代临时变量。
将临时变量的计算逻辑放到函数,也有助于在提炼得到的函数与原函数之间设立清晰的边界,这能帮我们发现并避免难缠的依赖及副作用。
原例:使用两个临时变量basePrice和discountFactor计算实际的price
1 | class Order(val quantity, val item) { |
运用以查询取代临时变量,把这两个临时变量提炼到函数:
1 | class Order(val quantity, val item) { |
隐藏委托关系和移除中间人 Hide Delegate and Remove Middle Man
隐藏委托关系(Hide Delegate)和移除中间人(Remove Middle Man)是两个相反的重构思路。
比如我们想得到某个省下面某个市的区号,可以用:
1 | val areaCode = provice().city().areaCode() |
这种写法出现了较长的消息链,如果不了解City的内部结构,是不知道如何得到areaCode。
像这个例子,先通过一个对象A得到另一个对象B,再利用另一个对象B获取结果,那使用方不仅要知道A的细节,还要知道B的细节才能正常工作。而封装的目的是为了让使用方尽可能少的了解其他系统部分的的细节信息。
隐藏委托关系,就是把获取B的结果封装到A内部,这个例子就可以改成:provice().areaCode()
1 | class Province { |
这样做的好处,一个是不需要让调用方有较多上下文包袱就能正常使用服务,另一个是后续areaCode的内部实现如果发生变化,不会污染到调用方。
但是,使用“隐藏委托关系”有个代价,会让A对象成为B的“中间人”。我们想知道B的能力,都必须先经过A。这样A的维护成本就比较高。而“移除中间人”就是把B直接交给服务方,避免过度中转,即provice().city().areaCode()。
所以,哪种情况使用隐藏委托关系,哪种情况使用移除中间人,很难说这个度是什么。《重构》一书中有较多反向的重构思路,比如提炼类和内联类、分解条件表达式和合并条件表达式、函数上移和函数下移等。具体原则的实践,多考虑开发效率、代码稳健性、团队理念,具体情况具体对待。
过胖的类“减肥”
类或方法过于庞大,比如一个方法五百行,一个类五千行,对于维护者和阅读者都不友好,前者不敢改,后者读的累。业务复杂后,这种情况还比较常见,代码加着加着,类就变的庞大了。面对这种情况,可以通过静态代码检查规则,设置“庞大”警戒线。比如方法或类超过多少行,就要提醒可以重构了。给类或方法减肥有很多方式,比如:
提炼类 Extract Class
当类中有几个彼此关联的概念,或者是几个变量有相同的前缀,那么可以考虑把这几个变量连带相关方法提炼到独立的类。
1 | class Person { |
区号、电话号码和Persong的常规理解不是特别紧密,可以把区号、电话号码提炼到单独的类:
1 | class Person { |
善用组合
通过组合,把特定功能代理出去,抽象成服务,然后以组合形式为业务类提供调用。
MVVM架构中,ViewModel可能要代理Api请求,处理数据中转、异常处理、业务埋点上报、性能监控等等。把这么多功能都放到ViewModel,这个类慢慢就会“变胖”。如果ViewModel还有继承体系,不同的子ViewModel需要定制监控、埋点等,那复杂度就更高了。一个解决思路是,把埋点、监控、异常处理等业务常规能力单独提炼成类,比如埋点服务、监控服务、异常处理服务,然后以组合的形式供ViewModel使用(即代理)。在提炼这些服务时,尽可能的抽象,减少耦合才能提高模块复用性。
以多态取代条件表达式,下放逻辑分支
一个庞大的类,往往有很多条件分支,对于不同情况做着不同的逻辑处理。这种情况下,参考《以多态取代条件表达式》,原本在Adapter中的条件分支和每个分支的View创建及刷新工作,以多态形式替代,Adapter分支的处理下方给各个View子类,同时把View的创建迁移到工厂类,让Adapter的逻辑更清晰。
我们项目的开发语言从Java完全切换到Kotlin,下面介绍几个可以让代码更优雅的Kotlin特性。
善用Extension [Kotlin]
Kotlin 能够扩展一个类的新功能而无需继承该类或者使用像装饰者这样的设计模式。通过两个例子感受下Extension的趣味。
三目运算符
之前使用Java,对于简短的if-else判断,我更喜欢用三目运算符?:
,但Kotlin没有这个特性。像下面这个case,就要写成:
1 | val bloodVolume = if (today is weekend) { |
习惯是很难改变的,如果是好的习惯,干嘛要改变呢。上面的case希望使用一行代码解决:
1 | val bloodVolume = ternary(today is weekend, { xxx() }, { yyy() }) |
ternary extension示例:
1 | inline fun <T> ternary(condition: Boolean, trueAction: () -> T, falseAction: ()-> T ): T = |
注意,为了性能考虑,记得加inline。
可空变量的let优化
像下面这个case,两个Nullable变量作为方法入参,但方法参数要求NonNull,需要写成:
1 | val result = param1?.let { p1 -> |
这个写法有点丑陋,真正的执行逻辑就一行代码调用,但5行代码中有4行是为了判空。试想下,如果方法入参从2个扩充到3个、4个…
简化写法示例如下:
1 | val result = Pair(param1, param2).biLet { p1, p2 -> |
通过对Pair的扩展,收敛let的判空逻辑。方法参数越多时,这个extension的简洁优势会更明显,比如tripleLet、quadrupleLet。
biLet extension代码如下:
1 | inline fun <T, U, R> Pair<T?, U?>.biLet(block: (T, U) -> R?): R? { |
更多关于Kotlin Extension的内容可以参考官网 >>。
Collection Pipeline [Kotlin]
《重构》的作者建议“Replace Loop with Pipeline”,pipeline就是集合自带的操作方法,比如filter、map、first、take等。通过pipeline api可以减少重复、丑陋的collection for loop操作。
filter & map
这两个api用的比较多,过滤出(filter)集合中符合条件的元素,并把元素中我们想要的内容提取出来(map)。
示例:专辑列表中,提取Taylor的专辑的名称
1 | val taylorAlbumNames = ArrayList<String>() |
filter { it is Album }
也可以使用filterIsInstance代替:
1 | taylorAlbumNames.addAll(albums.filterIsInstance<Album>().map { it.name }) |
kotlin中允许可空,比如albums = ArrayList<Albums?>()。在对album查找元素时,可以用filterNotNull过滤出非空元素再二次操作。
first & last
查找第一个符合条件的元素,比如:
1 | albums.first { it.name.contains("Taylor") } |
当albums中没有符合条件的元素时,使用first会抛出NoSuchElementException异常。安全做法也可以使用firstOrNull,找不到元素时返回null,比如:
1 | albums.firstOrNull { it.name.contains("Taylor") } |
更多Collection Pipeline Api的使用参考官网 >>。
关于命名
好的命名能够让阅读者对代码见名知意。前面一些代码示例中,也可以看到适当的修改方法命名会提高代码的可读性。网上有很多关于不同语言的命名规范,建议大家读一读,比如:
- 避免以个人主观理解对单词简写
- 包的名称总是小写且不使用下划线
- 类与对象的名称以大写字母开头并使用驼峰风格
- 常量名称应该使用大写、下划线分隔的名称
1 | const val MAX_COUNT = 8 |
更多内容参考Kotlin编码规范 >> 和 Kotlin 样式指南 >>。
关于注释
代码无法轻松表达语义时,我们更希望通过注释来提高阅读效率。注释并不是越多越好,对命名规范、见名知意的代码加上注释,会显得累赘。但是逻辑复杂、背景深邃的代码,就值得加上注释。
项目中我们鼓励同学们多加注释,因为起个好名字往往比加上注释要难。并且业务发展过快,代码逻辑很容易变得复杂。多加点注释,对于其他同学理解业务也是一个帮助。
还有一种鼓励加注释的情况是希望提高搜索效率。比如引用三方SDK。在SDK声明时(所有SDK的声明和版本号放在统一文件,方便管理)注释官方文档链接或团队整理的master doc(包含SDK使用说明、错误码、版本变更记录等),这样比在浩瀚的文档库中寻宝更高效。
总结
就像《三少爷的剑》,燕十三鄙夷三少爷的剑招都是简单的杀招,“击刺格洗,抽带提点,崩压搅挫,撩圈斩抹”,但他就是打不过人家。招数没有高下,有高下之别的是执剑人。代码优化的理念就像三少爷的剑术,不需要硬套复杂的设计原则、重构理念。本文说明的很多重构理念都是比较简单常见的,但是能把常见的重构方法、语言特性灵活运用,代码自然就会优雅。